![]() ![]() ![]() |
![]() |
Diesmal soll es nun aber wirklich losgehen mit der versprochenen Objektorientierung. Aber halt! Was für eine Sorte Programm soll eigentlich überhaupt entstehen? Ich denke hierbei an ein Vektor-Zeichenprogramm, etwas im Sinne von Amifig. Hierbei wählt der Anwender verschiedene Objekte - hoppla, da ist ja schon das richtige Wort - aus, wie etwa Linien, Kreise, Dreiecke, und plaziert sie auf dem Bildschirm. Im Gegensatz zu Pixelzeichenprogrammen wie DPaint oder PPaint behalten die gemalten Figuren aber ihre Eigenständigkeit auch nach dem Erscheinen auf der Zeichenfläche, können von dort aus wieder aufgenommen werden, woanders plaziert werden, skaliert, gedreht oder verschoben werden.
In einer "prozeduralen Programmiersprache" wie C würde man jetzt einzelne Funktionen bereitstellen, wie z.B. "ZeichneLinie" oder "ZeichneKreis" und würde diese aufrufen, wann immer das entsprechende Objekt auf dem Schirm erscheinen soll. Aber dies ist ein Kurs über C++, und wir wollen anders verfahren! Objekte wie "Kreis" oder "Linie" sollten wirklich genau das sein - Objekte nämlich - mit bestimmten Fähigkeiten, wie etwa die, sich selbst auf den Bildschirm zeichnen zu können, verschoben werden zu können und anderes. Wir werden diesmal noch nicht in die Programmierung der graphischen Oberfläche des AmigaOS einsteigen, sondern zunächst einmal in einer Art "Trockenschwimmen" einige der notwendigen Objekte erstellen. Hierzu lege man, wie im ersten Teil besprochen, zunächst ein neues C++- Projekt an. Nennen wir das Programm zunächst "Objekt.cpp".
Das Punkt-Objekt
Der einfachste Bestandteil einer Vektorgraphik ist wohl ein einzelner Punk. Er zeichnet sich nur durch seine Position aus, nämlich eine X-Koordinate und eine Y-Koordinate. Eine Linie ist dann gegeben durch ihre Endpunkte, zwei Punkte. Ein Kreis durch einen Punkt, den Mittelpunkt, und den Radius. Ein Rechteck durch zwei diagonal gegenüberliegende Eckpunkte. Wie bei einem Baukasten bauen wir also weitere Objekte auf der Basis des Punktes auf. Kümmern wir uns also zunächst nur um ein Punkt-Objekt. Folgende Zeilen, in Objekt.cpp eingetippt, bringen dem C++ Compiler bei, Punkte zu kennen:
class Punkt { int x; // X-Koordinate int y; // Y-Koordinate }; |
"class" ist hierbei die Anweisung an den Compiler, als nächstes die Definition eines Objektes zu erwarten, eine "Klasse" ist eine Sorte von Objekten, die C++ bereitstellt. Es gibt auch noch "structs", die wir zunächst nicht brauchen. Dahinter folgt der Objektname, wir nennen das Objekt "Punkt". Der Name ist willkürlich, aber man sollte sich etwas Sinngebendes ausdenken, sonst findet man sich irgendwann später nicht mehr im eigenen Programm zurecht. Zwischen den geschweiften Klammern { und } stehen die Bestandteile des Objektes, hier zwei "int", benannt x und y. Ein "int" ist einfach eine ganze Zahl. Wichtig ist, dass sowohl hinter der Objektdefinition als auch hinter der Definition seiner Komponenten jeweils ein Semikolon steht.
So ein Punkt ist ja ganz nett, aber er soll auch noch irgendetwas können. Insbesondere soll er sich zeichnen lassen können. Eine "Tätigkeit", die ein Objekt ausführen kann, nennt man eine "Methode". Ähnlich wie die beiden Komponenten x und y schreiben wir diese Methode in das Objekt selbst hinein; da wir im Augenblick noch im Trockendock arbeiten, soll anstelle des Zeichnens des Punktes vorerst nur ausgegeben werden, dass wir dies an dieser Stelle tun wollen. Konsolenausgabe hatten wir schon im ersten Teil besprochen: Dies geschieht mittels des "cout" Objektes, das wir dem Compiler mittels Einbindens von "iostream.h" erklären müssen. Damit sieht das Programm so aus:
#include <iostream.h> class Punkt { int x; // X-Koordinate int y; // Y-Koordinate void Zeichne(void) { cout << "Zeichne einen Punkt bei (" << x << "," << y << ").\n"; } }; int main(int argc, char **argv) { Punkt p; // mach' nen Punkt! p.Zeichne(); return 0; } |
Wie schon im ersten Teil, können wir auszugebende Daten einfach mittels << in cout hineinschieben, sie purzeln dann auf der Konsole heraus. Die Methode "Zeichne" liefert nichts - also void - zurück, im Gegensatz zu main, was immer eine Zahl, ein "int" zurückliefern muss. Argumente bekommt "Zeichne" auch nicht, wo der Punkt liegt, weiß er ja selbst. Der Zugriff auf die Koordinaten des Objektes, "x" und "y", geschieht innerhalb von "Zeichne" einfach mit deren Namen. Dem Compiler ist an dieser Stelle klar, dass hiermit die Komponenten, man sagt auch, die "Member" des eigenen Objektes gemeint sind.
Das untere ist das Hauptprogramm, das wie immer "main" heißen muss. Mittels "Punkt p" wird ein Objekt vom Typ "Punkt" erzeugt, und dieses Objekt bekommt den Namen "p". Die Zeile darunter, "p.Zeichen()", ruft die Methode "Zeichne" vom Objekt "p" auf. In den Klammern hinter "Zeichne" stehen die Argumente der Methode, und das sind - keine. Das Klammerpaar bleibt also leer.
Privatangelegenheiten
Versucht man nun das obige zu kompilieren, so gibt's bei mir hier folgende Fehlermeldung:
Error: No access to member "Zeichne" of class "Punkt".
Also, zu Deutsch: Keinen Zugriff auf Member "Zeichne" der Klasse "Punkt". Was ist geschehen? Diejenige Sorte von Objekten, die mit "class" definiert werden, schotten sich gegen die Umwelt ab. Man kommt "von außen" nicht ohne weiteres an die Innereien der Klasse heran. Das ist für gewöhnlich eine gute Idee, wenn mehrere Programmierer an einem Projekt arbeiten, aber jeder an seinen eigenen Objekten arbeitet: Es bleiben die Details der inneren Verdrahtung der Objekte nach außen verborgen, wodurch es dem Programmierer eines Objektes frei steht, diese zu ändern, ohne dass dadurch das gesamte Programm beeinträchtigt wird; kein anderer Programmteil kam bislang an das Innenleben des Objektes heran, kann also auch nicht davon abhängen.
Nun, das klingt jetzt zugegeben etwas doof: Wir haben ein Objekt, dürfen aber nichts damit anfangen? Gut, damit wir etwas damit anfangen können, müssen wir offensichtlich einige bestimmte Teile des Objektes von außen zugreifbar machen, und dies wäre offenbar das Zeichnen des Punktes; dazu ist die Objektdefinition wie folgt abzuändern:
class Punkt { int x; // X-Koordinate int y; // Y-Koordinate public: void Zeichne(void) { cout << "Zeichne einen Punkt bei (" << x << "," << y << ").\n"; } }; |
Mittels "public:" sagt man dem Compiler, dass die nun folgenden Member des Objektes öffentlich, also von außen zugänglich sind. Die Koordinaten x und y sind hiermit aber noch dem Punkt selbst vorbehalten und bleiben privat. Wir hätten dies auch explizit fordern können:
class Punkt { private: int x; // X-Koordinate int y; // Y-Koordinate public: void Zeichne(void) { cout << "Zeichne einen Punkt bei (" << x << "," << y << ").\n"; } }; |
"private" bedeutet nämlich genau das: Privat, Eintritt verboten!
Wird nun das so entstandene Programm kompiliert und gestartet, so entsteht bei mir auf der Konsole folgendes:
Zeichne einen Punkt bei (143435508,140284832).
Die Zahlen können durchaus auch anders aussehen. Wie kommt es zu diesen gigantischen Zahlen? Nun, wir haben zwar einen Punkt definiert, aber beim Erstellen des Punktes nicht gesagt, wo dieser zu liegen habe. Und nun liegt er da, wo immer der Computer Lust hatte, ihn hinzulegen. Offensichtlich nicht ganz das, was wir wollten! Wir müssen beim Erstellen des Punktes die Koordinaten mit angeben können. Genau dafür kann man eine bestimmte Methode definieren, einen sog. "Constructor". Er wird aufgerufen, wenn ein Objekt gebaut werden soll.
#include <iostream.h> class Punkt { int x; // X-Koordinate int y; // Y-Koordinate public: Punkt(int horizontal,int vertikal) { x = horizontal; y = vertikal; cout << "Erstelle einen Punkt bei (" << x << "," << y << ").\n"; } void Zeichne(void) { cout << "Zeichne einen Punkt bei (" << x << "," << y << ").\n"; } }; int main(int argc, char **argv) { Punkt p(1,2); // mach' nen Punkt bei (1,2)! p.Zeichne(); return 0; } |
Der Constructor heißt immer genauso wie das Objekt selbst - also auch "Punkt" - und hat keine Rückgabewerte. "void" darf man hier explizit nicht davorschreiben. Das ist zwar inkonsistent, aber historisch so entstanden. Im Constructor schieben wir die beiden Koordinaten "horizontal" und "vertikal" nach x und y und geben zur Kontrolle das Ergebnis nochmals aus. Jetzt, wo der Compiler weiß, dass zum Erstellen eines Punktes zwei Zahlen "horizontal" und "vertikal" notwendig sind, können wir mittels "Punkt p;" keinen Punkt mehr erstellen. Wir müssen schon explizit die geforderten Argumente des Constructors angeben! Dies passiert denn auch in der ersten Zeile von main().
Übrigens muss der Constructor auch öffentlich sein, sonst könnten wir in main() keine Punkte erstellen. Objekte mit privaten Konstruktoren können übrigens durchaus auch sinnvoll sein, aber dazu später mehr.
Das so erstellte Programm tut schon, was es soll: Ich erhalte auf dem Bildschirm folgendes:
Erstelle einen Punkt bei (1,2). Zeichne einen Punkt bei (1,2).
Also das erwartete! Zunächst wird ein Punkt erstellt, dann gezeichnet. Und dann? Ja, Punkte werden auch irgendwann gelöscht, nur bekommen wir davon nichts mit. Wir können dem Compiler aber sagen, das er etwas bestimmtes unternehmen soll, wenn wir Punkte löschen. Dies sagt der sogenannte "Destructor" eines Objektes, der als Methodennamen den Namen des Objektes mit einer Tilde davor bezeichnet wird, also "~Punkt" in diesem Falle. "~" bezeichnet in C und C++ den Operator der Komplementbildung, gelesen als "nicht". Dies ist also die "nicht-Punkt"-Methode, oder "das Komplement vom Constructor". Damit hätten wir:
#include <iostream.h> class Punkt { int x; // X-Koordinate int y; // Y-Koordinate public: Punkt(int horizontal,int vertikal) { x = horizontal; y = vertikal; cout << "Erstelle einen Punkt bei (" << x << "," << y << ").\n"; } ~Punkt(void) { cout << "Lösche einen Punkt.\n"; } void Zeichne(void) { cout << "Zeichne einen Punkt bei (" << x << "," << y << ").\n"; } }; int main(int argc, char **argv) { Punkt p(1,2); // mach' nen Punkt bei (1,2)! p.Zeichne(); return 0; } |
Das so erhaltene Programm schreibt dann folgendes auf den Schirm:
Erstelle einen Punkt bei (1,2). Zeichne einen Punkt bei (1,2). Lösche einen Punkt.
Das ist ganz wie erwartet! Wo aber haucht denn nun der Punkt sein Leben aus? Nun, es gilt bei den hier verwendeten "automatischen" Objekten, wir werden noch andere Sorten kennenlernen, dass sie dann zerstört werden, wenn die geschweifte Klammer innerhalb derer sie erzeugt wurden, wieder geschlossen wird. Der Punkt geht also genau vor dem Ende von main, vor der }-Klammer wieder kaputt.
Das Linien-Objekt
Zwei Punkte machen eine Linie, insofern ist das Linienobjekt relativ offensichtlich zu erstellen; das folgende Code-Fragment ist hinter die Definition von "Punkt" einzufügen.
class Linie { Punkt anfang; Punkt ende; public: Linie(Punkt von, Punkt bis) : anfang(von), ende(bis) { cout << "Eine Linie wurde soeben erzeugt.\n"; } // ~Linie(void) { cout << "Eine Linie wurde soeben gelöscht.\n"; } }; |
Der Constructor der Linie sieht diesmal ein wenig seltsam aus, denn die Syntax mit dem Doppelpunkt ist neu; hier werden, ganz analog dem Punkt- Beispiel, "anfang" und "ende" mit den Werten von "von" und "bis" initialisiert. Man hätte hier alternativ auch
Linie(Punkt von, Punkt bis) { anfang = von; ende = bis; cout << "Eine Linie wurde soeben erzeugt.\n"; } |
schreiben können, was dasselbe bewirkt hätte. Aber man will ja etwas lernen! Diese Sonderbedeutung des Doppelpunktes gibt es aber nur für Constructors. Eingefleischte C++-Hasen werden vielleicht anmerken, dass es zwischen dem oberen und dem unteren Code einige diffizile Unterschiede gibt, was dessen genaue Bedeutung angeht, aber ich möchte darauf im Augenblick nicht eingehen, um es nicht komplizierter als nötig zu machen. In unserem Falle macht's sowieso keinen Unterschied.
Fehlt noch das Zeichnen von Linien: Nun ja, hierzu bräuchten wir natürlich die Koordinaten der Punkte. Wie wäre es hiermit:
void Zeichne(void) { cout << "Zeichne eine Linie von " << "(" << anfang.x << "," << anfang.y << ") bis "<< "(" << ende.y << "," << ende.y << ").\n"; } |
Der Punkt hat wieder die Bedeutung von "Member von", also bezeichnet "anfang.x" "die X-Komponente des Punktes anfang des Objektes, von der Zeichne eine Methode ist". Das ist zwar korrektes C++, aber dennoch meckert der Compiler beim Übersetzungsversuch:
Error: No access to member "x" of class "Punkt"
Richtig! "x" und "y" waren ja "privat", und damit kann die Linie darauf nicht zurückgreifen.
Für dieses Problem gibt es mehrere Lösungen:
1) Wir machen x und y-Komponente des Punktes öffentlich. Damit wären wir zwar das Problem los, aber ein gutes Design wäre das sicherlich nicht. Eins der Ziele der Objektorientierung war ja gerade, Privates von Öffentlichem zu trennen.
2) Wir "befreunden" die Linie mit dem Punkt; Freunde dürfen sich gegenseitig in die Karten schauen, und somit dürfte das Linie-Objekt auch auf die privaten Member des Punkt-Objektes sehen. Die C++-Syntax dafür sähe so aus:
class Linie; class Punkt { friend class Linie; // ... weiter wie gehabt... |
Mit der Zeile oberhalb von "class Punkt" geben wir dem Compiler zunächst mal einen Tipp, dass es ein Objekt namens "Linie" geben wird, welches wir weiter unten dann genauer erklären. Mittels der Zeile "friend class Linie" im Punkt sagen wir, dass der Punkt mit der Linie befreundet ist. Die Linie darf dann also in die Privatangelegenheiten des Punktes hineinsehen.
Auch das würde funktionieren, ist aber auch nicht ganz nach meinem Geschmack; mittels "friend" weicht man eine strenge Objektstruktur auf und baut Hintertürchen ein; eine solche verlotterte Objektstruktur artet dann meist aus. Außerdem könnte sowohl unter 1) als auch unter 2) eine Linie die Koordinaten ihrer Punkte ändern - doch das sollte der Punkt doch lieber selbst tun! Bleibt hart! Es gibt bessere Methoden!
3) Die bessere Methode besteht darin, dass der Punkt "auf Anfrage" seine Koordinaten preisgibt, d.h. man baue zwei Methoden in den Punkt ein, die seine X bzw. Y-Koordinate verraten, ohne dass man jedoch diese dazu verwenden könnte, sie auch zu verändern. Hierzu muss das Programm nun wie folgt abgeändert werden:
#include <iostream.h> class Punkt { int x; // X-Koordinate int y; // Y-Koordinate public: Punkt(int horizontal,int vertikal) { x = horizontal; y = vertikal; cout << "Erstelle einen Punkt bei (" << x << "," << y << ").\n"; } ~Punkt(void) { cout << "Lösche einen Punkt.\n"; } void Zeichne(void) { cout << "Zeichne einen Punkt bei (" << x << "," << y << ").\n"; } int X_Hiervon(void) // verrate die X-Komponente { return x; } int Y_Hiervon(void) // verrate die Y-Komponente { return y; } }; class Linie { Punkt anfang; Punkt ende; public: Linie(Punkt von, Punkt bis) : anfang(von), ende(bis) { cout << "Eine Linie wurde soeben erzeugt.\n"; } // ~Linie(void) { cout << "Eine Linie wurde soeben gelöscht.\n"; } // void Zeichne(void) { cout << "Zeichne eine Linie von " << "(" << anfang.X_Hiervon() << "," << anfang.Y_Hiervon() << ") bis "<< "(" << ende.X_Hiervon() << "," << ende.Y_Hiervon() << ").\n"; } }; int main(int argc, char **argv) { Punkt p(1,2); // mach' nen Punkt bei (1,2)! Linie l(Punkt(3,4),Punkt(5,6)); p.Zeichne(); l.Zeichne(); return 0; } |
Die Methoden "X_Hiervon()" und "Y_Hiervon()" des Punktes sind somit neu, und geben die X bzw. Y-Koordinate des Punktes zurück. Dadurch kann man diese Koordinaten zwar lesen, aber nicht ändern, was genau der gewünschte Effekt ist.
Man nennt solche Methoden auch "Accessor-Funktionen", da sie den Zugriff - "Access" - auf bestimmte Member erlauben. Im Linie-Objekt wurde die "Zeichne()"-Methode dahingehend verändert, dass nun die Zugriffsfunktionen der Punkte aufgerufen werden, statt direkt auf die Member zurückzugreifen. Im Hauptprogramm erstelle ich zusätzlich eine Linie namens "l", die mit zwei Punkten initialisiert wird: Der Startpunkt mittels "Punkt(3,4)" und der Endpunkt mittels "Punkt(5,6)". Die Linie wird außerdem noch mittels "l.Zeichne()" "gemalt", zumindest wird ausgegeben, dass wir das wollen.
Die fehlenden Punkte
Dieses Programm kompiliert nun prima, und gibt folgenden Text aus:
Erstelle einen Punkt bei (1,2). Erstelle einen Punkt bei (5,6). Erstelle einen Punkt bei (3,4). Eine Linie wurde soeben erzeugt. Lösche einen Punkt. Lösche einen Punkt. Zeichne einen Punkt bei (1,2). Zeichne eine Linie von (3,4) bis (5,6). Eine Linie wurde soeben gelöscht. Lösche einen Punkt. Lösche einen Punkt. Lösche einen Punkt.
Aufmerksame Leser werden feststellen, dass hier zwar wie erwartet drei Punkte erzeugt werden - der einzelne Punkt bei (1,2) und die Start- und Endpunkte der Linien bei (3,4) und (5,6) - aber es werden fünf Punkte gelöscht. Nanu? Was ist denn da passiert? Wird da etwas gelöscht, was nicht erstellt wurde?
Doch, es geht hier alles mit rechten Dingen zu, nur zwei Erstellungsvorgänge finden im Verborgenen statt. Des Rätsels Lösung sind die beiden Endpunkte der Linie: Der Compiler lässt diese zunächst als temporäre Objekte entstehen; mit diesen temporären Objekten wird der Constructor der Linie aufgerufen. Die temporären Objekte dienen dann zum Erstellen der Member "anfang" und "ende" der Linie, und *dieser* Vorgang bleibt uns verborgen; danach werden die temporären Punkte wieder gelöscht - das geschieht direkt nach Erstellen der Linie. Die restlichen Löschvorgänge sind die des einzelnen Punktes, und der beiden Endpunkte der Linie, wie erwartet.
Noch ein Wort zum verborgenen Erstellungsvorgang: Hierbei wird ein "eingebauter" des Constructor des Punkt-Objektes aufgerufen, der zwar immer da ist, aber nicht explizit programmiert werden zu braucht; dies ist der sogenannte "Copy-Constructor", der einen neuen Punkt aus einem alten baut. Der eingebaute Constructor kopiert die Objekte einfach komponentenweise, was hier auch genau das richtige ist. Wir können ihn allerdings auch ausprogrammieren und damit den verborgenen Kopiervorgang sichtbar machen. Folgende Zeilen sind in das Punkt-Objekt einzufügen:
// // der Copy-Constructor des Punktes Punkt(const Punkt &orginal) { x = orginal.x; y = orginal.y; cout << "Ein Punkt wurde geklont.\n"; } // |
Die Aufrufparameter sehen etwas sonderlich aus: "const" bedeutet, dass der Parameter nicht vom Copy-Constructor überschrieben wird, was sich von selbst versteht - das Original soll ja beim Erstellen eines neuen Punktes nicht leiden.
Das "&" vor dem Parameter? Nun ja, ignorieren wir es im Augenblick, das ist etwas knifflig zu erklären. Es muss hier stehen, sonst klappt der Trick nicht. Der Rumpf des Constructors hingegen ist leicht zu erklären: Die x- und y-Koordinaten werden vom Original in den "Klon" eingetragen, und eine Nachricht darüber kommt auf die Konsole.
Wird nun dieses Programm kompiliert und ausgeführt, so ergibt sich:
Erstelle einen Punkt bei (1,2). Erstelle einen Punkt bei (5,6). Erstelle einen Punkt bei (3,4). Ein Punkt wurde geklont. Ein Punkt wurde geklont. Eine Linie wurde soeben erzeugt. Lösche einen Punkt. Lösche einen Punkt. Zeichne einen Punkt bei (1,2). Zeichne eine Linie von (3,4) bis (5,6). Eine Linie wurde soeben gelöscht. Lösche einen Punkt. Lösche einen Punkt. Lösche einen Punkt.
Voilá, jetzt werden genau so viele Punkte erstellt wie gelöscht, wovon zwei - nämlich Start- und Endpunkt der Linie - durch Klonen aus den Argumenten hervorgehen.
Ausblicke
Wichtige Konzepte in dieser Folge war einerseits das "Information Hiding", d.h. das Verbergen von internen Verschaltungen eines Objektes vor Zugriffen von außen, als auch die Erstellung von Constructors, Destructors und Methoden. Trotz alledem ist das augenblickliche Programm noch recht unbefriedigend:
cout << Punkt(1,2);schreiben könnten. Das lässt sich in C++ durchaus bewerkstelligen, bedarf aber einiger weiterer Konzepte.
Insbesondere der letzte Punkt ist doch recht nervig, ging es doch darum, ein Zeichenprogramm zu erstellen. Insofern werden wir dem in der nächsten Folge abhelfen.
Thomas Richter <thor@math.TU-Berlin.DE>